Skip to content

feat(eip712): device-driven field streaming for structured typed data - #39

Merged
BitHighlander merged 1 commit into
masterfrom
feat/eip712-structured-streaming
Aug 21, 2026
Merged

feat(eip712): device-driven field streaming for structured typed data#39
BitHighlander merged 1 commit into
masterfrom
feat/eip712-structured-streaming

Conversation

@BitHighlander

Copy link
Copy Markdown
Owner

Five messages (1704–1708) that let the device drive an EIP-712 walk instead of being handed the document.

The device asks for one type definition, or one leaf value, at a time, and hashes each value in the same pass that displays it. The host owns the document; the device holds only the digest stack for the containers currently open. A 10,000-element array costs the same RAM as a 2-element one.

This replaces Ethereum712TypesValues, which shipped the whole thing as two 2048-byte JSON blobs and was withdrawn in 7.14.2 because its parser could not guarantee the value on screen was the value being hashed. Here that is structural rather than reviewed — a value is displayed and absorbed from the same buffer in the same call, and each member_path is requested exactly once.

That last clause is not incidental: Trezor shipped this same protocol with a hole until 2.12.0 — nothing bound repeated answers for one path together, so a host could answer the domain name one way for the summary screen and another for the hashing pass.

One deliberate divergence: arrays

Trezor and OneKey describe an array as a field whose entry_type is another EthereumFieldType — a self-referential message. Trezor can, because core is Python. OneKey does it on nanopb by compiling that field as a pointer (PB_ENABLE_MALLOC) then flattening the chain into a pool to sever the recursion.

KeepKey's nanopb is static-allocation only, and a heap inside a signing device isn't a liability worth taking on for one field. So nesting is flattened, the way Ledger describes it:

uint256        -> UINT,   size=32,               array_levels=[]
address[]      -> ADDRESS,                       array_levels=[0]
Person[3]      -> STRUCT, struct_name="Person",  array_levels=[3]
int16[2][][4]  -> INT,    size=2,                array_levels=[2,0,4]

Nothing EIP-712 permits is lost. Enum values still match Trezor's so a shared host keeps its mapping.

Values arrive as raw big-endian bytes, not JSON — deleting the decimal-parsing step and with it the 64-bit integer ceiling that made the old path refuse an unlimited approval, the most common permit there is.

Validated with protoc 3.5.1 in kktech/firmware:v8.

Five messages (1704-1708) that let the device drive an EIP-712 walk instead of
being handed the document.

The device asks for one type definition, or one leaf VALUE, at a time, and
hashes each value in the same pass that displays it. The host owns the
document; the device holds only the digest stack for the containers currently
open. A 10,000-element array costs the same RAM as a 2-element one, and there
is no document-size limit to raise later.

This replaces Ethereum712TypesValues, which shipped the whole thing as two
2048-byte JSON blobs and was withdrawn in 7.14.2 because its parser could not
guarantee the value on screen was the value being hashed. Here that is
structural rather than reviewed: a value is displayed and absorbed from the
same buffer in the same call, and each member_path is requested exactly once.

That last clause is not incidental. Trezor shipped this same protocol with a
hole until 2.12.0 -- nothing bound repeated answers for one path to each other,
so a host could answer the domain name one way for the summary screen and
another for the hashing pass. Requesting each path once closes it by
construction rather than by caching around it.

ONE deliberate divergence from Trezor and OneKey: arrays.

Both describe an array as a field whose entry_type is another EthereumFieldType
-- a self-referential message. Trezor can, because core is Python. OneKey does
it on nanopb by compiling that one field as a POINTER (PB_ENABLE_MALLOC) and
then flattening the pointer chain into a fixed pool to sever the recursion.

KeepKey's nanopb is static-allocation only, and a heap inside a signing device
is not a liability worth taking on for one field. So array nesting is FLATTENED
onto the wire the way Ledger describes it: data_type is always the LEAF type,
and array_levels carries the dimensions in written order, 0 for dynamic:

    uint256        -> UINT,   size=32,               array_levels=[]
    address[]      -> ADDRESS,                       array_levels=[0]
    Person[3]      -> STRUCT, struct_name="Person",  array_levels=[3]
    int16[2][][4]  -> INT,    size=2,                array_levels=[2,0,4]

Nothing EIP-712 permits is lost and the encoding is bounded, flat and
statically sized. Enum values still match Trezor's so a shared host keeps its
mapping; ARRAY is reserved and never sent.

Values arrive as raw big-endian bytes of the declared width, not JSON. That
deletes the whole decimal-parsing step from the device -- and with it the
64-bit integer ceiling that made the old path refuse an unlimited approval,
which is the most common permit there is.

Validated with protoc 3.5.1 in kktech/firmware:v8.
@BitHighlander
BitHighlander merged commit a1a1dda into master Aug 21, 2026
2 checks passed
@BitHighlander
BitHighlander deleted the feat/eip712-structured-streaming branch August 21, 2026 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant